# -*- coding: utf-8 -*-
# Project : flacon
# File : converter.py
# (c) Alexander Sokolov sokoloff.a@gmail.com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.



#import errno
import exceptions
import re
import sys
import time
import glob
import os
import math
import traceback

from PyQt4 import QtCore
from PyQt4.QtCore import *

from flaconglobal import *
from disc import *
from errors import *
import cuecreator

class Converter(QObject):
   #****************************************************************
   #
   #****************************************************************
    def __init__(self, parent = None):
        QObject.__init__(self, parent)
        self._threads = []
        self._threadCount = 0
        self._startedTime = 0
        self._started = False


   #****************************************************************
   #
   #****************************************************************
    def start(self, project):
        self._startedTime = time.time()
        self._started = True
        self._project = project

        (self._threadCount, ok) = settings.value('Encoder/ThreadCount').toInt()
        if self._threadCount<1:
            self._threadCount = max(6, QThread.idealThreadCount())


        formatId = settings.value('OutFiles/Format').toString()
        format = outputAudioFormats[formatId]
        gainType = settings.value(format.id + "/ReplayGain").toString()

        encoderClass = format.encoderClass
        albumGainClass = None
        trackGainClass = None

        if gainType == ReplayGainTypes.Album:
            albumGainClass = encoderClass.albumGainClass()

        if gainType == ReplayGainTypes.Track:
            trackGainClass = encoderClass.trackGainClass()

        self._check(project, [Splitter, encoderClass, trackGainClass, albumGainClass])

        for disc in self._project:
            if disc.canConvert():
                self._createDiscThreads(disc, encoderClass, trackGainClass, albumGainClass)

        for thread in self._threads:
            self.connect(thread, SIGNAL("error(int,int,QString)"), self._threadError)
            self.connect(thread, SIGNAL("readyStart()"), self.startThread)
            self.connect(thread, SIGNAL("finished()"), self._threadFinished)
            self.connect(thread, SIGNAL("progress(int,int,int,int)"), self._threadProgressChanged)

        self._createDirs()
        self.startThread()


   #****************************************************************
   #  CREATE THREADS CHAINS
   #****************************************************************
   #        SIGNAL(trackReady) --> SLOT(inputDataReady)
   #
   #             +--> Encoder ---> Track gain -->+
   #  Splitter ->+            ...                +-> Album gain --> self
   #             +--> Encoder ---> Track gain -->+
   #
   #                               optional step    optional step
   #****************************************************************

    def _createDiscThreads(self, disc, encoderClass, trackGainClass, albumGainClass):
        # Add splitter ...............................
        splitter = Splitter(disc)
        self._threads.append(splitter)

        # Add AlbumGain ..............................
        albumGain = None
        if albumGainClass:
            albumGain = albumGainClass(disc, range(0, disc.tracks().count()))
            self.connect(albumGain, SIGNAL("trackReady(int,int,QString)"), self.inputDataReady)
            self._threads.append(albumGain)

        # Per track cue ..............................
        if settings.value('PerTrackCue/Create').toBool():
            preGapTrack = PreGapTrack(disc.tracks())
            encoder = encoderClass(disc, preGapTrack)
            self.connect(splitter, SIGNAL("preGapReady(int,int,QString)"), encoder.inputDataReady)
            self._threads.append(encoder)

            cueCreator = cuecreator.CueCreator(disc)
            cueCreator.setPregapFileName(preGapTrack.resultFileName())
            splitter.setCueCreator(cueCreator)


        for track in disc.tracks():
            # Add Encoder ............................
            encoder = encoderClass(disc, track)
            self.connect(splitter, SIGNAL("trackReady(int,int,QString)"), encoder.inputDataReady)
            self._threads.append(encoder)
            prev = encoder

            # Add Track Gain .........................
            if trackGainClass:
                trackGain = trackGainClass(disc, [disc.tracks().indexOf(track)])
                self.connect(encoder, SIGNAL("trackReady(int,int,QString)"), trackGain.inputDataReady)
                self._threads.append(trackGain)
                prev = trackGain

            # Connect last thread to endPoint
            if albumGain:
                self.connect(prev, SIGNAL("trackReady(int,int,QString)"), albumGain.inputDataReady)
            else:
                self.connect(prev, SIGNAL("trackReady(int,int,QString)"), self.inputDataReady)


   #****************************************************************
   #
   #****************************************************************
    def _check(self, project, classes):
        errs = QStringList()
        for c in classes:
            try:
                if c: c.check(project)

            except FlaconException, e:
                errs << QString("<li style='margin-top: 4px;'>%1</li>").arg(e.message)

        if errs.count() > 0:
            msg = QString("<html>%1<ul>%2</ul></html>").arg(self.tr('Conversion is not possible:'), errs.join('\n'))
            raise Exception(unicode(msg))


   #****************************************************************
   #
   #****************************************************************
    def stop(self, disc = None):
        if not self.isRunning():
            return

        for disc in self._project:
            self._stopDiscThreads(disc)


   #****************************************************************
   # Stop all threads for disc
   #****************************************************************
    def _stopDiscThreads(self, disc):
        for thread in self._threads:
            if thread.disc() == disc:
                thread.stop()
                thread.wait()

        discNum = self._project.indexOf(disc)
        for track in disc.tracks():
            status = track.status()

            if status == Statuses.NotRunning:
                self._threadProgressChanged(discNum, track.index(), Statuses.Canceled, -1)
            elif status != Statuses.Error and status != Statuses.OK:
                self._threadProgressChanged(discNum, track.index(), Statuses.Aborted, -1)


   #****************************************************************
   #
   #****************************************************************
    def startThread(self):
        count = self._threadCount
        splitterCount = max(1, math.ceil(count / 2))

        for thread in self._threads:
            if count == 0:
                break

            if thread.isFinished():
                continue

            if isinstance(thread, Splitter):
                if thread.isRunning():
                    count -= 1
                    splitterCount -= 1
                else:
                    if thread.isReadyStart() and splitterCount > 0:
                        thread.start()
                        count -= 1
                        splitterCount -= 1

            else:
                if thread.isRunning():
                    count -= 1
                else:
                    if thread.isReadyStart():
                        thread.start()
                        count -= 1


   #****************************************************************
   #
   #****************************************************************
    def _createDirs(self):
        dirs = set()
        for thread in self._threads:
            if thread._workDir:
                dirs.add(QString(thread._workDir))

        for disc in self._project:
            if disc.canConvert():
                for track in disc.tracks():
                    dirs.add(QFileInfo(track.resultFilePath()).dir().absolutePath())

        for dirName in dirs:
            dir = QDir(dirName)
            created = dir.mkpath('.')
            writeable = QFileInfo(dir.path()).isWritable()

            if not created:
                raise ConvertError(self.tr("Can't create directory \"%1\".").arg(dir.path()))

            if not writeable:
                raise ConvertError(self.tr("Can't write to directory \"%1\".").arg(dir.path()))


   #****************************************************************
   #
   #****************************************************************
    def _threadProgressChanged(self, discNum, trackNum, status, progress):
        if trackNum > -1:
            track = self._project[discNum].tracks()[trackNum]
            track.setProgress(status, progress)
        self.emit(SIGNAL('progressChanged(int,int)'), discNum, trackNum)


   #****************************************************************
   # Error handler, terminate all worked threads
   #****************************************************************
    def _threadError(self, discNum, trackNum, msg):
        disc = self._project[discNum]
        self._stopDiscThreads(disc)
        self.emit(SIGNAL("error(QString)"), msg)


   #****************************************************************
   #
   #****************************************************************
    def _threadFinished(self):
        if not self._started:
           return

        self.startThread()

        if not self.isRunning():
            self._started = False
            del self._threads[:]
            self.emit(SIGNAL("finished()"))
            length = time.time() - self._startedTime
            print "Encoding time %d sec." %  length


   #****************************************************************
   #
   #****************************************************************
    def inputDataReady(self, discIndex, trackIndex, fileName):
        self._threadProgressChanged(discIndex, trackIndex, Statuses.OK, -1)


   #****************************************************************
   #
   #****************************************************************
    def isFinished(self):
        for thread in self._threads:
            if not thread.isFinished():
                return False

        return True


   #****************************************************************
   #
   #****************************************************************
    def isRunning(self):
        for thread in self._threads:
            if thread.isRunning():
                return True

        return False



class ConverterThread(QThread):
   #****************************************************************
   #
   #****************************************************************
    def __init__(self, disc):
        QThread.__init__(self)
        self._disc = disc
        self._discIndex = disc.project().indexOf(disc)
        self._cancel = False
        self._trackIndex = -1
        self._mutex = QMutex()
        self._workDir = None


   #****************************************************************
   #
   #****************************************************************
    def disc(self):
        return self._disc


   #****************************************************************
   #
   #****************************************************************
    def trackIndex(self):
        return self._trackIndex


   #****************************************************************
   #
   #****************************************************************
    def stop(self):
        self._cancel = True


   #****************************************************************
   #
   #****************************************************************
    def inputDataReady(self, diskIndex, trackIndex, fileName):
        raise TypeError('Abstract method "ConverterThread.inputDataReady" called')


   #****************************************************************
   #
   #****************************************************************
    def progressChanged(self, status, percent, trackIndex = None):
        if trackIndex is None:
            trackIndex = self._trackIndex
        self.emit(SIGNAL("progress(int,int,int,int)"), self._discIndex, trackIndex, status, percent)


   #****************************************************************
   #
   #****************************************************************
    def trackReady(self, fileName, trackIndex = None):
        if trackIndex is None:
            trackIndex = self._trackIndex

        self.emit(SIGNAL("trackReady(int,int,QString)"), self._discIndex, trackIndex, fileName)


   #****************************************************************
   #
   #****************************************************************
    def tr(self, text, disambiguation = ''):
        res = QtGui.qApp.translate(self.__class__.__name__, text, disambiguation)

        if res == text:
            res = QtGui.qApp.translate("ConverterThread", text, disambiguation)

        return res


   #****************************************************************
   #
   #****************************************************************
    def run(self):
        try:
            self._run()

        except Abort, e:
            return

        except Exception, e:
            if not isinstance(e, FlaconException):
                traceback.print_exc()

            if self._trackIndex < 0:
                self._trackIndex = 0
            self.progressChanged(Statuses.Error, -1)
            self.emit(SIGNAL("error(int,int,QString)"), self._discIndex, self._trackIndex, QString(unicode(e)))
            return



class Splitter(ConverterThread):


   #****************************************************************
   #
   #****************************************************************
    def __init__(self, disc):
        ConverterThread.__init__(self, disc)
        self._errors = QStringList()
        self._tracks = self._disc.tracks()
        self._filePrefix = "tmp-%d-%d-" % (os.getpid(), self._discIndex)
        self._cueCreator = None
        self._preGapExists = False

        prefTmpDir = settings.value('Encoder/TmpDir').toString()
        if prefTmpDir.isEmpty():
            self._workDir = QFileInfo(self._tracks[0].resultFilePath()).dir().absolutePath()
        else:
            self._workDir = QDir(prefTmpDir + ('/flacon.%s' % os.getpid())).absolutePath()


   #****************************************************************
   #
   #****************************************************************
    def cueCreator(self):
        return self._cueCreator


   #****************************************************************
   #
   #****************************************************************
    def setCueCreator(self, cueCreator):
        self._cueCreator = cueCreator


   #****************************************************************
   #
   #****************************************************************
    def isReadyStart(self):
        return True


   #****************************************************************
   # Split ape to temporary dir
   #****************************************************************
    def _run(self):
        args = QStringList()
        args << settings.value("Programs/shntool").toString()
        args << 'split'
        args << '-w'
        args << '-O' << 'always'
        args << '-n' << '%04d'
        args << '-t' << self._filePrefix +'%n'
        args << '-d' << self._workDir
        args << self._disc.audioFileName()
        #print "%s" % args.join(' ')

        proc = tryPopen(args , stdin=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True)

        self.sendCueData(proc)
        proc.stdin.close()
        self.parseOut(proc)
        res = proc.wait()
        if res:
            raise ConvertError(self._errors.join("\n"))

        if self._cueCreator:
            t = self._disc.tracks()[0]
            dir = QFileInfo(t.resultFilePath()).dir().absolutePath()
            file = Track.safeString(QString("%1-%2.cue").arg(t.artist(), t.album()))

            if not self._preGapExists:
               self._cueCreator.setPregapFileName('')

            self._cueCreator.write(dir + "/" + file)


   #****************************************************************
   # Fix shnsplit error: m:ss.ff format can only be used with CD-quality files
   # I don't know last part in frames or in milliseconds?
   #****************************************************************
    def sendCueData(self, proc):
        cdQuality = (self._disc.audioFile().cdQuality())
        fakeIndex = (settings.value('PerTrackCue/Create').toBool() and
                     settings.value('PerTrackCue/Pregap').toString() == PregapFileTypes.AddToFirstTrack)

        cueData = self._disc.cueData()
        trackNum = 0


        for line in cueData:
            line = line.trimmed()

            if (line.section(' ', 0, 0).toUpper() == 'TRACK'):
                trackNum += 1

            if line.section(' ', 0, 0).toUpper() == 'INDEX':
                indexNum = int(line.section(' ', 1, 1))

                if (trackNum == 1) and (fakeIndex):

                    if indexNum == 1:
                        if cdQuality:
                            proc.stdin.write("FAKE INDEX 01 00:00:00\n")
                        else:
                            proc.stdin.write("FAKE INDEX 01 00:00.00\n")

                    continue

                else:
                    if not cdQuality:
                        ff = int(line.section(':', 2, 2))
                        line = QString("%1.%2").arg(line.section(':', 0, 1)).arg(ff*10, 3, 10, QChar('0'))


            proc.stdin.write(line.toUtf8())
            proc.stdin.write("\n")


   #****************************************************************
   #
   #****************************************************************
    def parseOut(self, proc):
        codec = QTextCodec.codecForLocale()
        buff = ''
        trackRe = re.compile('/' + self._filePrefix + '(\d{1,4})\.wav\]\s+\(\d{1,2}:\d{2,2}\.\d{1,3}\)\s+:\s*\d+%')

        while True:
            if self._cancel:
                proc.terminate()
                proc.wait()
                raise Abort()

            c = proc.stderr.read(1)
            if not c: break
            buff += c
            #sys.stdout.write(c)

            #***************************************************
            if (c == "\n"):
                # Errorrs
                if buff.find(': error:') > -1:
                    self._errors << codec.toUnicode(buff[17:])

                buff = ''

            #***************************************************
            elif (c == '%'):
                percent = int((buff[-4:-1]))
                r = trackRe.search(buff)
                if (r):
                    self._trackIndex = int(r.groups()[0])-1

                if percent == 100:
                    fileName = QString('%1/%2%3.wav').arg(self._workDir).arg(self._filePrefix).arg(self._trackIndex+1, 4, 10, QChar('0'))
                    if self._trackIndex > -1:
                        self.trackReady(fileName)

                    else:
                        if settings.value('PerTrackCue/Create').toBool():
                            self._preGapExists = True
                            self.emit(SIGNAL("preGapReady(int,int,QString)"), self._discIndex, -1, fileName)
                        else:
                            self.deleteFile(fileName)

                else:
                    self.progressChanged(Statuses.Extracting, percent)


   #****************************************************************
   #
   #****************************************************************
    def deleteFile(self, fileName):
        f = QFile(fileName)
        if f.exists() and not f.remove():
            raise ConvertError(self.tr("Can't delete file:\n%1\n%2").arg(f.fileName()).arg(f.errorString()))


   #****************************************************************
   #
   #****************************************************************
    @staticmethod
    def check(out):
        if not settings.checkProgram('Programs/shntool'):
            out << 'shntool'




#*******************************************************************
# Load encoder modules
#*******************************************************************
dir = modulesDir() + "/encoders"
sys.path.append(dir)

files = glob.glob(dir + '/*_encoder.py')
for f in files:
    module = os.path.basename(f)[:-3]
    __import__(module)

